<?php
/** @package    verysimple::Phreeze */

/**
 * Phreezable Class
 *
 * Abstract base class for object that are persistable by Phreeze
 *
 * @package verysimple::Phreeze
 * @author VerySimple Inc. <noreply@verysimple.com>
 * @copyright 1997-2005 VerySimple Inc.
 * @license http://www.gnu.org/licenses/lgpl.html LGPL
 * @version 1.3
 */
abstract class Phreezable implements Serializable
{
    private $_cache = array ();
    protected $_phreezer;
    protected $_val_errors = array ();
    protected $_base_validation_complete = false;
    private $_isLoaded;
    private $_isPartiallyLoaded;
    private $_cacheLevel = 0;
    private $_noCache = false;
    
    /** @var these properties will never be cached */
    private static $NoCacheProperties = array (
            "_cache",
            "_phreezer",
            "_val_errors",
            "_base_validation_complete"
    );
    
    /** @var cache of public properties for each type for improved performance when enumerating */
    private static $PublicPropCache = array ();
    
    /**
     * Returns true if the current object has been loaded
     *
     * @access public
     * @param
     *          bool (optional) if provided will change the value
     * @return bool
     */
    public function IsLoaded($value = null)
    {
        if ($value != null) {
            $this->_isLoaded = $value;
        }

        return $this->_isLoaded;
    }
    
    /**
     * Returns true if the current object has been partially loaded
     *
     * @access public
     * @param
     *          bool (optional) if provided will change the value
     * @return bool
     */
    public function IsPartiallyLoaded($value = null)
    {
        if ($value != null) {
            $this->_isPartiallyLoaded = $value;
        }

        return $this->_isPartiallyLoaded;
    }
    
    /**
     * Returns 0 if this was loaded from the DB, 1 if from 1st level cache and 2 if 2nd level cache
     *
     * @access public
     * @param
     *          bool (optional) if provided will change the value
     * @return bool
     */
    public function CacheLevel($value = null)
    {
        if ($value != null) {
            $this->_cacheLevel = $value;
        }

        return $this->_cacheLevel;
    }
    
    /**
     * Returns true if the current object should never be cached
     *
     * @access public
     * @param
     *          bool (optional) if provided will change the value
     * @return bool
     */
    public function NoCache($value = null)
    {
        if ($value != null) {
            $this->_noCache = $value;
        }

        return $this->_noCache;
    }
    
    /**
     * Returns an array with all public properties, excluding any internal
     * properties used by the Phreeze framework.
     * This is cached for performance
     * when enumerating through large numbers of the same class
     *
     * @return array
     */
    public function GetPublicProperties()
    {
        $className = get_class($this);
        
        if (! array_key_exists($className, self::$PublicPropCache)) {
            $props = array ();
            $ro = new ReflectionObject($this);
            
            foreach ($ro->getProperties() as $rp) {
                $propname = $rp->getName();
                
                if (! in_array($propname, self::$NoCacheProperties)) {
                    if (! ($rp->isPrivate() || $rp->isStatic())) {
                        $props [] = $propname;
                    }
                }
            }
            
            self::$PublicPropCache [$className] = $props;
        }
        
        return self::$PublicPropCache [$className];
    }
    
    /**
     * When serializing, make sure that we ommit certain properties that
     * should never be cached or serialized.
     */
    function serialize()
    {
        $propvals = array ();
        $ro = new ReflectionObject($this);
        
        foreach ($ro->getProperties() as $rp) {
            $propname = $rp->getName();
            
            if (! in_array($propname, self::$NoCacheProperties)) {
                if (method_exists($rp, "setAccessible")) {
                    $rp->setAccessible(true);
                    $propvals [$propname] = $rp->getValue($this);
                } elseif (! $rp->isPrivate()) {
                    // if < php 5.3 we can't serialize private vars
                    $propvals [$propname] = $rp->getValue($this);
                }
            }
        }
        
        return serialize($propvals);
    }
    
    /**
     *
     * @deprecated use ToObject
     */
    function GetObject($props = null, $camelCase = false)
    {
        return $this->ToObject(array (
                'props' => $props,
                'camelCase' => $camelCase
        ));
    }
    
    /**
     * Return an object with a limited number of properties from this Phreezable object.
     * This can be used if not all properties are necessary, for example rendering as JSON
     *
     * This can be overriden per class for custom JSON output. the overridden method may accept
     * additional option parameters that are not supported by the base Phreezable calss
     *
     * @param
     *          array assoc array of options. This is passed through from Controller->RenderJSON
     *          props (array) array of props to return (if null then use all public props)
     *          omit (array) array of props to omit
     *          camelCase (bool) if true then first letter of each property is made lowercase
     * @return stdClass
     */
    function ToObject($options = null)
    {
        if ($options === null) {
            $options = array ();
        }

        $props = array_key_exists('props', $options) ? $options ['props'] : $this->GetPublicProperties();
        $omit = array_key_exists('omit', $options) ? $options ['omit'] : array ();
        $camelCase = array_key_exists('camelCase', $options) ? $options ['camelCase'] : false;
        
        $obj = new stdClass();
        
        foreach ($props as $prop) {
            if (! in_array($prop, $omit)) {
                $newProp = ($camelCase) ? lcfirst($prop) : $prop;
                $obj->$newProp = $this->$prop;
            }
        }
        
        return $obj;
    }
    
    /**
     * Reload the object when it awakes from serialization
     *
     * @param
     *          $data
     */
    function unserialize($data)
    {
        $propvals = unserialize($data);
        $ro = new ReflectionObject($this);
        
        foreach ($ro->getProperties() as $rp) {
            $propname = $rp->name;
            if (array_key_exists($propname, $propvals)) {
                if (method_exists($rp, "setAccessible")) {
                    $rp->setAccessible(true);
                    $rp->setValue($this, $propvals [$propname]);
                } elseif (! $rp->isPrivate()) {
                    // if < php 5.3 we can't serialize private vars
                    $rp->setValue($this, $propvals [$propname]);
                }
            }
        }
    }
    
    /**
     * constructor
     *
     * @access public
     * @param Phreezer $phreezer
     * @param Array $row
     */
    final function __construct(Phreezer $phreezer, $row = null)
    {
        $this->_phreezer = $phreezer;
        $this->_cache = array ();
        
        if ($row) {
            $this->Init();
            $this->Load($row);
        } else {
            $this->LoadDefaults();
            $this->Init();
        }
    }
    
    /**
     * Init is called after contruction.
     * When loading, Init is called prior to Load().
     * When creating a blank object, Init is called immediately after LoadDefaults()
     *
     * @access public
     */
    public function Init()
    {
    }
    
    /**
     * LoadDefaults is called during construction if this object is not instantiated with
     * a DB row.
     * The default values as specified in the fieldmap are loaded
     *
     * @access public
     */
    public function LoadDefaults()
    {
        $fms = $this->_phreezer->GetFieldMaps(get_class($this));
        
        foreach ($fms as $fm) {
            $prop = $fm->PropertyName;
            $this->$prop = $fm->DefaultValue;
        }
    }
    
    /**
     * LoadFromObject allows this class to be populated from another class, so long as
     * the properties are compatible.
     * This is useful when using reporters so that you
     * can easily convert them to phreezable objects. Be sure to check that IsLoaded
     * is true before attempting to save this object.
     *
     * @access public
     * @param $src the
     *          object to populate from, which must contain compatible properties
     */
    public function LoadFromObject($src)
    {
        $this->IsLoaded(true);
        $src_cls = get_class($src);
        
        foreach (get_object_vars($this) as $key => $val) {
            if (substr($key, 0, 1) != "_") {
                if (property_exists($src_cls, $key)) {
                    $this->$key = $src->$key;
                    $this->IsPartiallyLoaded(true);
                } else {
                    $this->IsLoaded(false);
                }
            }
        }
        
        $this->OnLoad();
    }
    
    /**
     * Validate returns true if the properties all contain valid values.
     * If not,
     * use GetValidationErrors to see which fields have invalid values.
     *
     * @access public
     */
    public function Validate()
    {
        // force re-validation
        $this->ResetValidationErrors();
        
        $is_valid = (! $this->HasValidationErrors());
        
        // if validation fails, remove this object from the cache otherwise invalid values can
        // hang around and cause troubles.
        if (! $is_valid) {
            $this->_phreezer->DeleteCache(get_class($this), $this->GetPrimaryKeyValue());
        }
        
        return $is_valid;
    }
    
    /**
     * Add a validation error to the error array
     *
     * @param
     *          string property name
     * @param
     *          string error message
     */
    protected function AddValidationError($prop, $msg)
    {
        $this->_val_errors [$prop] = $msg;
    }
    
    /**
     * Returns true if this object has validation errors
     *
     * @return bool
     */
    protected function HasValidationErrors()
    {
        $this->_DoBaseValidation();
        return count($this->_val_errors) > 0;
    }
    
    /**
     * Returns the error array - containing an array of fields with invalid values.
     *
     * @access public
     * @return array
     */
    public function GetValidationErrors()
    {
        $this->_DoBaseValidation();
        return $this->_val_errors;
    }
    
    /**
     * Clears all previous validation errors
     */
    protected function ResetValidationErrors()
    {
        $this->_val_errors = array ();
        $this->_base_validation_complete = false;
    }
    
    /**
     * populates the _val_errors array w/ phreezer
     *
     * @access private
     */
    private function _DoBaseValidation()
    {
        $lenfunction = $this->_phreezer->DataAdapter->ConnectionSetting->Multibyte ? 'mb_strlen' : 'strlen';
        
        if (! $this->_base_validation_complete) {
            $fms = $this->_phreezer->GetFieldMaps(get_class($this));
            
            foreach ($fms as $fm) {
                $prop = $fm->PropertyName;
                
                if ($fm->FieldType == FM_TYPE_DECIMAL && is_numeric($fm->FieldSize)) {
                    // decimal validation needs to be treated differently than whole numbers
                    
                    $values = explode('.', ( string ) $this->$prop, 2);
                    $right = count($values) > 1 ? strlen(( string ) $values [1]) : 0;
                    $left = strlen(( string ) $values [0]);
                    
                    $limits = explode('.', ( string ) $fm->FieldSize, 2);
                    $limitRight = count($limits) > 1 ? ( int ) $limits [1] : 0;
                    $limitLeft = ( int ) $limits [0] - $limitRight;
                    
                    if ($left > $limitLeft || $right > $limitRight) {
                        $this->AddValidationError($prop, "$prop exceeds the maximum length of " . $fm->FieldSize . "");
                    }
                } elseif (is_numeric($fm->FieldSize) && ($lenfunction ( $this->$prop )-1 > $fm->FieldSize)) {
                    $this->AddValidationError($prop, "$prop exceeds the maximum length of " . $fm->FieldSize . "");
                }
                
                if ($this->$prop == "" && ($fm->DefaultValue || $fm->IsAutoInsert)) {
                    // these fields are auto-populated so we don't need to validate them unless
                    // a specific value was provided
                } else {
                    switch ($fm->FieldType) {
                        case FM_TYPE_INT:
                        case FM_TYPE_SMALLINT:
                        case FM_TYPE_TINYINT:
                        case FM_TYPE_MEDIUMINT:
                        case FM_TYPE_BIGINT:
                        case FM_TYPE_DECIMAL:
                            if (! is_numeric($this->$prop)) {
                                $this->AddValidationError($prop, "$prop is not a valid number");
                            }
                            break;
                        case FM_TYPE_DATE:
                        case FM_TYPE_DATETIME:
                            if (strtotime($this->$prop) === '') {
                                $this->AddValidationError($prop, "$prop is not a valid date/time value.");
                            }
                            break;
                        case FM_TYPE_ENUM:
                            if (! in_array($this->$prop, $fm->GetEnumValues())) {
                                $this->AddValidationError($prop, "$prop is not valid value. Allowed values: " . implode(', ', $fm->GetEnumValues()));
                            }
                            break;
                        default:
                            break;
                    }
                }
            }
        }
        
        // print_r($this->_val_errors);
        
        $this->_base_validation_complete = true;
    }
    
    /**
     * This static function can be overridden to populate this object with
     * results of a custom query
     *
     * @access public
     * @param Criteria $criteria
     * @return string or null
     */
    public static function GetCustomQuery($criteria)
    {
        return null;
    }
    
    /**
     * Refresh the object in the event that it has been saved to the session or serialized
     *
     * @access public
     * @param Phreezer $phreezer
     * @param Array $row
     */
    final function Refresh(&$phreezer, $row = null)
    {
        $this->_phreezer = $phreezer;
        
        // also refresh any children in the cache in case they are accessed
        foreach ($this->_cache as $child) {
            if (in_array("Phreezable", class_parents($child))) {
                $child->Refresh($phreezer, $row);
            }
        }
        
        if ($row) {
            $this->Load($row);
        }
        
        $this->OnRefresh();
    }
    
    /**
     * Serialized string representation of this object.
     * For sorting
     * purposes it is recommended to override this method
     */
    function ToString()
    {
        return serialize($this);
    }
    
    /**
     * Returns the name of the primary key property.
     * TODO: does not support multiple primary keys.
     *
     * @access public
     * @return string
     */
    function GetPrimaryKeyName()
    {
        $fms = $this->_phreezer->GetFieldMaps(get_class($this));
        foreach ($fms as $fm) {
            if ($fm->IsPrimaryKey) {
                return $fm->PropertyName;
            }
        }
        
        /*
		 * print "<pre>";
		 * $this->Data = "";
		 * $this->_phreezer = null;
		 * $this->_cache = null;
		 * print_r($this);
		 *
		 * print_r($fms);
		 * die();
		 */
        
        throw new Exception("No Primary Key found for " . get_class($this));
    }
    
    /**
     * Returns the value of the primary key property.
     * TODO: does not support multiple primary keys.
     *
     * @access public
     * @return string
     */
    function GetPrimaryKeyValue()
    {
        $prop = $this->GetPrimaryKeyName();
        return $this->$prop;
    }
    
    /**
     * Returns this object as an associative array with properties as keys and
     * values as values
     *
     * @access public
     * @return array
     */
    function GetArray()
    {
        $fms = $this->_phreezer->GetFieldMaps(get_class($this));
        $cols = array ();
        
        foreach ($fms as $fm) {
            $prop = $fm->PropertyName;
            $cols [$fm->ColumnName] = $this->$prop;
        }
        
        return $cols;
    }
    
    /**
     * Persist this object to the data store
     *
     * @access public
     * @param bool $force_insert
     *          (default = false)
     * @return int auto_increment or number of records affected
     */
    function Save($force_insert = false)
    {
        return $this->_phreezer->Save($this, $force_insert);
    }
    
    /**
     * Delete this object from the data store
     *
     * @access public
     * @return int number of records affected
     */
    function Delete()
    {
        return $this->_phreezer->Delete($this);
    }
    
    /**
     * Loads the object with data given in the row array.
     *
     * @access public
     * @param Array $row
     */
    function Load(&$row)
    {
        $fms = $this->_phreezer->GetFieldMaps(get_class($this));
        $this->_phreezer->Observe("Loading " . get_class($this), OBSERVE_DEBUG);
        
        $this->IsLoaded(true); // assume true until fail occurs
        $this->IsPartiallyLoaded(false); // at least we tried
                                         
        // in order to prevent collisions on fields, QueryBuilder appends __tablename__rand to the
                                         // sql statement. We need to strip that out so we can match it up to the property names
        $rowlocal = array ();
        foreach ($row as $key => $val) {
            $info = explode("___", $key);
            
            // we prefer to use tablename.colname if we have it, but if not
            // just use the colname
            $newkey = isset($info [1]) ? ($info [1] . "." . $info [0]) : $info [0];
            if (isset($rowlocal [$newkey])) {
                throw new Exception("The column `$newkey` was selected twice in the same query, causing a data collision");
            }

            $rowlocal [$newkey] = $val;
        }
        
        foreach ($fms as $fm) {
            if (array_key_exists($fm->TableName . "." . $fm->ColumnName, $rowlocal)) {
                // first try to locate the field by tablename.colname
                $prop = $fm->PropertyName;
                $this->$prop = $rowlocal [$fm->TableName . "." . $fm->ColumnName];
            } elseif (array_key_exists($fm->ColumnName, $rowlocal)) {
                // if we can't locate the field by tablename.colname, then just look for colname
                $prop = $fm->PropertyName;
                $this->$prop = $rowlocal [$fm->ColumnName];
            } else {
                // there is a required column missing from this $row array - mark as partially loaded
                $this->_phreezer->Observe("Missing column '" . $fm->ColumnName . "' while loading " . get_class($this), OBSERVE_WARN);
                $this->IsLoaded(false);
                $this->IsPartiallyLoaded(true);
            }
        }
        
        // now look for any eagerly loaded children - their fields should be available in this query
        $kms = $this->_phreezer->GetKeyMaps(get_class($this));
        
        foreach ($kms as $km) {
            if ($km->LoadType == KM_LOAD_EAGER || $km->LoadType == KM_LOAD_INNER) {
                // load the child object that was obtained eagerly and cache so we
                // won't ever grab the same object twice in one page load
                $this->_phreezer->IncludeModel($km->ForeignObject);
                $foclass = $km->ForeignObject;
                $fo = new $foclass ( $this->_phreezer, $row );
                
                $this->_phreezer->SetCache($foclass, $fo->GetPrimaryKeyValue(), $fo, $this->_phreezer->CacheQueryObjectLevel2);
            }
        }

        $this->_phreezer->Observe("Firing " . get_class($this) . "->OnLoad()", OBSERVE_DEBUG);
        $this->OnLoad();
    }
    
    /**
     * Returns a value from the local cache
     *
     * @access public
     * @deprecated this is handled internally by Phreezer now
     * @param string $key
     * @return object
     */
    public function GetCache($key)
    {
        return (array_key_exists($key, $this->_cache) ? $this->_cache [$key] : null);
    }
    
    /**
     * Sets a value from in local cache
     *
     * @access public
     * @deprecated this is handled internally by Phreezer now
     * @param string $key
     * @param object $obj
     */
    public function SetCache($key, $obj)
    {
        $this->_cache [$key] = $obj;
    }
    
    /**
     * Clears all values in the local cache
     *
     * @access public
     * @deprecated this is handled internally by Phreezer now
     */
    public function ClearCache()
    {
        $this->_cache = array ();
    }
    
    /**
     * Called after object is loaded, may be overridden
     *
     * @access protected
     */
    protected function OnLoad()
    {
    }
    
    /**
     * Called by Phreezer prior to saving the object, may be overridden.
     * If this function returns any non-true value, then the save operation
     * will be cancelled. This allows you to perform custom insert/update queries
     * if necessary
     *
     * @access protected
     * @param boolean $is_insert
     *          true if Phreezer considers this a new record
     * @return boolean
     */
    public function OnSave($is_insert)
    {
        return true;
    }
    
    /**
     * Called by Phreezer after object is updated, may be overridden
     *
     * @access public
     */
    public function OnUpdate()
    {
    }
    
    /**
     * Called by Phreezer after object is inserted, may be overridden
     *
     * @access public
     */
    public function OnInsert()
    {
    }
    
    /**
     * Called by Phreezer after object is deleted, may be overridden
     *
     * @access public
     */
    public function OnDelete()
    {
    }
    
    /**
     * Called by Phreezer before object is deleted, may be overridden.
     * if a true value is not returned, the delete operation will be aborted
     *
     * @access public
     * @return bool
     */
    public function OnBeforeDelete()
    {
        return true;
    }
    
    /**
     * Called after object is refreshed, may be overridden
     *
     * @access public
     */
    public function OnRefresh()
    {
    }
    
    /**
     * Throw an exception if an undeclared property is accessed
     *
     * @access public
     * @param string $key
     * @throws Exception
     */
    public function __get($key)
    {
        throw new Exception("Unknown property: $key");
    }
    
    /**
     * Throw an exception if an undeclared property is accessed
     *
     * @access public
     * @param string $key
     * @param string $val
     * @throws Exception
     */
    public function __set($key, $val)
    {
        throw new Exception("Unknown property: $key");
    }
}
